到昨天為止,我的遊戲有四個獨立的變數在記錄「現在的情況」:
const hero = {...}; // 勇者資料
const enemy = {...}; // 小怪資料
let whipCount = 0; // 鞭屍次數
let battleStarted = false; // 戰鬥是否開始
四個東西,散在檔案各處。今天要把它們收進同一個盒子。
Day 12 回顧的時候,我寫過自己卡關的真正原因:
我一直在回頭找「我要的這個動作該加在哪一行」,還要在腦中模擬:執行到這裡時,每個變數是多少?
變數越散,腦中要追蹤的東西越多。 具體的麻煩有三個:
| 問題 | 實際情況 |
|---|---|
| debug 要到處找 | 想知道「現在遊戲是什麼狀態」,得同時看四個地方 |
| 容易漏掉 | 做「再戰一次」時,很容易忘記重置 whipCount |
| 無法一次觀察 | 沒辦法用一行 console.log 看到全貌 |
const state = {
hero: { name: "勇者", hp: 100, maxHp: 100, atk: 20, def: 5 },
enemy: { name: "小怪", hp: 60, maxHp: 60, atk: 15, def: 3 },
turn: 1,
whipCount: 0,
battleStarted: false,
isOver: false,
};
觀念上沒有新東西 —— 它就是 Day 6 學的物件,跟 Python 的字典是同一回事。真正的改變不是語法,是把「哪些資訊屬於遊戲狀態」這件事明確化。
這叫 single source of truth(單一事實來源):每個資訊只有一個權威的存放位置。想知道現在的情況,只要看 state 一個地方。
用 const 宣告但內容照樣能改 —— Day 6 驗證過的規則,在這裡變成優點:整包 state 不會被意外換掉,但裡面的值隨時能更新。

重構的時候我加了兩個新欄位。
state.turn:記錄第幾回合,畫面上也顯示出來。之前根本沒有回合概念,但 Day 17 決定要做「最少回合挑戰」,這個數字現在有了用途。
state.isOver:遊戲是否結束。以前判斷「已經死了」是看 enemy.hp <= 0,那是從結果反推狀態。現在改成明確的旗標:
if (state.isOver) { // 以前是 if (enemy.hp <= 0)
// 鞭屍分支
}
差別在哪?如果之後加入「小怪逃跑」「戰鬥中斷」這類結束方式,血量還是滿的,但遊戲確實結束了 —— 那時候 hp <= 0 就完全失效。用旗標記錄「狀態」本身,比從數值去猜要可靠。
有了 state 之後,我在每次攻擊後加了一行:
console.log("目前狀態:", state); // 一行看見全貌
理論上這樣就能留下每一回合的完整紀錄。連續攻擊四下、打贏、再鞭屍幾下,主控台確實留下了八筆紀錄。
但展開來看的時候,問題出現了:顯示的全部是最後的狀態,不是每個階段當下的狀態。

這張截圖把整件事講得很清楚。同一批紀錄:
| 紀錄 | 摺疊起來的預覽 | 點開之後 |
|---|---|---|
| 第 1 筆 | turn: 2, whipCount: 0 |
turn: 4, whipCount: 1 |
| 第 2 筆 | turn: 3, whipCount: 0 |
turn: 4, whipCount: 4 |
| 第 4 筆 | turn: 4, whipCount: 0 |
turn: 4, whipCount: 4 |
摺疊時那排灰色的預覽文字是正確的歷史 —— 第一次攻擊時 turn 真的是 2。但只要一展開,它就去讀「現在」的 state,全部變成最新的值。
這就是 Day 6 那個活參照陷阱。當時我是在三個元素的陣列上撞到它,今天是整個遊戲狀態。
而且有個更細的地方:第 1 筆展開後是 whipCount: 1,第 2 筆卻是 4 —— 因為我展開它們的時間點不同。展開的那一瞬間讀一次,然後畫面就固定在那裡了。
這件事值得停下來想一下。
狀態集中之後,「一行 console.log 看見全貌」確實做到了。但同一個動作現在包含了整個遊戲 —— 而它整包都是活的參照。以前印一個 whipCount 頂多錯一個數字,現在一次錯八個欄位。
工具讓觀察變容易,但沒有讓紀錄變可靠。 想留下真正的歷史快照,還是得自己動手:
console.log(structuredClone(state)); // 深層複製一份
console.log(JSON.parse(JSON.stringify(state))); // 老方法,同樣有效
(Day 6 學的 [...arr] 在這裡不夠用,因為它只複製第一層 —— state.hero 還是同一個物件。這種「只複製表面」和「連裡面一起複製」的差別,是我今天新學到的。)
老實說,寫的當下感覺不大 —— 因為觀念上沒學到新東西,只是把同樣的資料換個地方放。hero.hp 變成 state.hero.hp,還多打幾個字。
但有兩個地方立刻感受到差別:
state,整個遊戲的狀態流動一目瞭然 —— 哪些函式會改狀態、哪些只是讀,一眼就分得出來第二點特別重要。集中管理沒有自動幫我避免錯誤,但它給了我一張可以對照的清單。這大概就是「工程化」的意思:不是變得更聰明,是讓犯錯更容易被發現。
今天搞懂的事:
state 物件語法上就是 Day 6 的物件,真正的改變是把「什麼算狀態」明確化
isOver)記錄狀態,比從數值(hp <= 0)反推可靠console.log 的活參照陷阱也跟著放大[...arr] 只複製第一層;要完整快照得用 structuredClone 或 JSON 轉一圈明天學非同步 —— Day 17 那個「排到清單最後面」的機制,會正式變成主角。